spb/chat-spboucher-ai
Public
1"use client";2import { useEffect, useState } from "react";3import { useParams, useRouter } from "next/navigation";4import useSWR, { mutate as gmutate } from "swr";5import { FolderKanban, Archive, Trash2, Check, AlertTriangle } from "lucide-react";6import { api, fetcher } from "@/lib/client/fetcher";7import type { ArtifactDTO, MemoryDTO, ProjectDTO } from "@/lib/client/types";8import { Button, Spinner, Tabs, cn, toast } from "@/components/ui";9import { ColorPicker } from "@/components/projects/project-shared";10import { ArtifactsTab, ConversationsTab, FilesTab, InstructionsTab, MemoryTab, NotesTab, type ProjectFile } from "@/components/projects/project-tabs";1112interface Resp { project: ProjectDTO; files: ProjectFile[]; memories: MemoryDTO[]; artifacts: ArtifactDTO[] }13type Tab = "conversations" | "instructions" | "files" | "memory" | "notes" | "artifacts";1415export default function ProjectPage() {16 const { id } = useParams<{ id: string }>(); const router = useRouter();17 const { data, error, mutate } = useSWR<Resp>(`/api/projects/${id}`, fetcher);18 const [tab, setTab] = useState<Tab>("conversations");19 const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [editingHead, setEditingHead] = useState(false);20 useEffect(() => { if (data) { setName(data.project.name); setDescription(data.project.description ?? ""); } }, [data]);21 const refresh = () => { void mutate(); void gmutate("/api/projects"); };22 async function patch(p: Record<string, unknown>, msg?: string) { try { await api(`/api/projects/${id}`, { method: "PATCH", json: p }); if (msg) toast(msg, "ok"); refresh(); } catch (e) { toast((e as Error).message, "error"); } }23 async function saveHead() { await patch({ name: name.trim() || data?.project.name, description: description.trim() || null }, "Projet mis à jour"); setEditingHead(false); }24 async function archive() { if (!confirm("Archiver ce projet ? Ses conversations restent accessibles dans l'historique.")) return; await patch({ archived: true }, "Projet archivé"); router.push("/"); }25 async function del() { if (!confirm("Supprimer définitivement ce projet ? Les conversations sont conservées mais détachées ; les fichiers et souvenirs du projet sont supprimés.")) return; await api(`/api/projects/${id}`, { method: "DELETE" }); void gmutate("/api/projects"); toast("Projet supprimé", "ok"); router.push("/"); }2627 if (error) return <div className="p-8 text-sm text-danger">Projet introuvable.</div>;28 if (!data) return <div className="flex flex-1 items-center justify-center"><Spinner className="h-5 w-5" /></div>;29 const p = data.project;30 const tabs: { value: Tab; label: string; count?: number }[] = [31 { value: "conversations", label: "Conversations", count: p.conversation_count }, { value: "instructions", label: "Instructions" }, { value: "files", label: "Fichiers", count: data.files.length },32 { value: "memory", label: "Mémoire", count: data.memories.length }, { value: "notes", label: "Notes" }, { value: "artifacts", label: "Artefacts", count: data.artifacts.length },33 ];34 return (35 <div className="min-h-0 flex-1 overflow-y-auto">36 <div className="mx-auto w-full max-w-4xl px-4 py-6 md:px-8">37 <div className="mb-5 flex flex-wrap items-start gap-3">38 <span className="mt-1.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl" style={{ background: `${p.color ?? "#6d9cff"}22`, color: p.color ?? "var(--accent)" }}><FolderKanban className="h-5 w-5" /></span>39 <div className="min-w-0 flex-1">40 {editingHead ? (41 <div className="space-y-2">42 <input value={name} onChange={e => setName(e.target.value)} maxLength={120} className="input text-lg font-semibold" autoFocus />43 <input value={description} onChange={e => setDescription(e.target.value)} maxLength={2000} className="input text-[13px]" placeholder="Description" />44 <div className="flex items-center gap-3"><ColorPicker value={p.color} onChange={c => patch({ color: c })} /><span className="flex-1" /><Button variant="ghost" size="sm" onClick={() => setEditingHead(false)}>Annuler</Button><Button variant="primary" size="sm" onClick={saveHead}><Check className="h-3.5 w-3.5" /> Enregistrer</Button></div>45 </div>46 ) : (47 <button onClick={() => setEditingHead(true)} className="group block w-full text-left" title="Modifier le nom, la description et la couleur">48 <h1 className="truncate text-xl font-semibold tracking-tight group-hover:text-accent">{p.name}</h1>49 <p className="mt-0.5 text-[13px] text-muted">{p.description || "Ajouter une description…"}</p>50 </button>51 )}52 </div>53 </div>5455 <div className="mb-5 -mx-1 overflow-x-auto px-1">56 <Tabs value={tab} onChange={setTab} items={tabs.map(t => ({ value: t.value, label: <span>{t.label}{t.count != null ? <span className="ml-1 text-muted">{t.count}</span> : null}</span> }))} />57 </div>5859 <div className={cn("anim-fade-up")} key={tab}>60 {tab === "conversations" && <ConversationsTab project={p} />}61 {tab === "instructions" && <InstructionsTab project={p} onSaved={refresh} />}62 {tab === "files" && <FilesTab project={p} files={data.files} onChanged={refresh} />}63 {tab === "memory" && <MemoryTab project={p} memories={data.memories} onChanged={refresh} />}64 {tab === "notes" && <NotesTab project={p} />}65 {tab === "artifacts" && <ArtifactsTab artifacts={data.artifacts} />}66 </div>6768 <section className="mt-12 rounded-2xl border border-danger/30 p-5">69 <h2 className="mb-1 flex items-center gap-2 text-[13px] font-semibold uppercase tracking-wider text-danger"><AlertTriangle className="h-4 w-4" /> Zone sensible</h2>70 <p className="mb-3 text-[13px] text-muted">Archiver masque le projet de la barre latérale sans rien supprimer. Supprimer efface les fichiers, notes et souvenirs du projet ; les conversations sont conservées sans projet.</p>71 <div className="flex flex-wrap gap-2"><Button variant="outline" onClick={archive}><Archive className="h-4 w-4" /> Archiver</Button><Button variant="danger" onClick={del}><Trash2 className="h-4 w-4" /> Supprimer le projet</Button></div>72 </section>73 </div>74 </div>75 );76}77